相信在iOS开发中大家都用过倒计时的功能,而NSTimer也是大家用得最多用来实现该功能的类,但是可能有人不太清楚NSTimer存在计时不准并且可能会导致引用循环资源无法释放的情况,接下来我会介绍一下使用GCD以及CADisplaylink来实现倒计时以及他们三者的利弊。
RunLoop
在开始介绍下面三种方法之前,我想我们有必要先来介绍一下RunLoop,因为CADisplaylink和NSTimer都是需要通过运行在RunLoop里面才保证了每次到特定的时间点就会执行对应的事件
RunLoop是什么
一般来说线程只能执行一次任务,执行完任务之后就会退出,可是如果需要处理多个任务呢,那就需要RunLoop来保证线程能随时处理事件并且不会退出。
RunLoop实际上像是一个对象,该对象提供了一个入口函数,该入口函数会实现像不断的循环获取任务执行任务的功能,当线程执行了这个入口函数之后,就会一直处于函数内部:接受任务->等待->处理这样的循环中,知道接受到quit消息,就会推出该入口函数,然后线程销毁。
RunLoop和线程之间的关系
RunLoop和线程是一一对应的,它们通过key-value的形式保存在一个全局的字典里面(key是p_thread,value是CFRunLoopRef),iOS中不允许直接创建RunLoop,可以通过两个方法获取RunLoop ,CFRunLoopGetMain() 和 CFRunLoopGetCurrent()。这两个方法内部会调用CFRunLoopRef _CFRunLoopGet(pthread_t thread)
方法,调用该方法时,会优先判断该字典是不是为空,如果是的话就创建一个以pthread_main_thread_np()为key的runloop并且放到字典里面。然后在字典中寻找thread为key的runloop,如果不存在则创建一个新的RunLoop并且注册一个回调,当线程销毁时,也销毁RunLoop。
RunLoopMode
苹果提供了两个公开的RunLoopMode,NSDefaultRunLoop以及UITrackingRunLoopMode,第一个mode程序默认的mode,当程序中有ScrollView滚动的时候,RunLoop就会将当前的mode切换为UITrackingRunLoopMode。
相信很多人都有过NSTimer在默认情况下可用,当APP有ScrollView在滚动的时候就不可用的回调,那是因为NSTimer加到runloop里面的时候默认是NSDefaultRunLoop,当页面滑动的时候RunLoop切换到了UITrackingRunLoopMode,所以timer就不起作用了,这个时候需要使用
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
将timer标记为NSRunLoopCommonModes,NSRunLoopCommonModes默认是包含了NSDefaultRunLoop和UITrackingRunLoopMode两个model。
RunLoop的结构
我们首先来看一下RunLoop的都包含什么东西:
1 | struct __CFRunLoopMode { |
_CFRunLoop:
_commonModes: 一个标记为common的集合,通过
CFRunLoopAddCommonMode(CFRunLoopRef runloop, CFStringRef modeName);
可以将一个mode加到commonModes里面,当RunLoop的内容变化的时候,RunLoop都会将Timer/Observer/Sources同步到标记为“common”的Mode里面_commonModeItems: 被加到CommonModes里面的所有的Item的集合,一个Item包含_source0,_source1,_observers,_timers。
_currentMode: 当前RunLoop的mode,可以通过
CFRunLoopRunInMode(CFStringRef modeName, ...);
来切换mode_modes:RunLoop包含的mode
_CFRunLoopMode:
source0(CFRunLoopSourceRef): mode的事件源,source0只包含一个回调(函数指针),它并不会自动触发,需要先调用
CFRunLoopSourceSinal(source)
将source标为待处理,然后调用CFRunLoopSourceWakeUp(source)
来唤醒RunLoop才会调用这个方法。_sources1(CFRunLoopSourceRef): mode的另外一个事件源,source1包含了回调以及一个mach-port,被用于通过内核和其他线程相互发送消息。这种 Source 能主动唤醒 RunLoop 的线程
_timers: 是基于时间的触发器,它和 NSTimer 是toll-free bridged 的,可以混用。其包含一个时间长度和一个回调(函数指针)
_observers: 是观察者,每个 Observer 都包含了一个回调(函数指针),当 RunLoop 的状态发生变化时,观察者就能通过回调接受到这个变化。
更多的关于RunLoop的内容可以看深入理解RunLoop
NSTimer
我们通常会使用以下的代码来创建一个Timer并且将Timer加到RunLoop里面。
1 | sellf.timer = [NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(doSomeThing) userInfo:nil repeats:YES]; |
以上的代码会有两个问题:
- 内存泄漏的问题,首先RunLoop会强引用timer,而timer会强引用self,所以timer不释放的时候,self也无法释放。通常我们会用以下的代码来释放Timer,但是需要找一个合适的时机去释放它,加入我们像以下代码那样在viewWillDisapper那样释放它,当回到主屏幕的时候那timer又要被销毁了,然后重新进入重新创建一个timer,这样就会非常麻烦,需要多个地方维护timer的状态。
1 | - (void)viewWillDisappear:(BOOL)animated{ |
很明显我们这里要解决的就是timer的释放时机的问题,我们当然是希望持有timer的视图控制器执行dealloc释放的时候释放它,但是这时候千万别企图在dealloc方法里面做这个事情,原因自己想。
我们的思路就是通过创建一个MagicClass来弱应用这个target,然后timer的target强引用这个MagicClass,执行MagicClass的一个替身Action,在这个Action里面我们可以判断target是不是被销毁了(因为这个时候没有Timer强引用它,所以不会有有内存泄漏的问题),然后没有被销毁则执行真正的Action,如果Target已经被销毁了则调用invalidate销毁timer。
here is the code
NSTimer+LMExtension.h
1 | #import <Foundation/Foundation.h> |
NSTimer+LMExtension.m
1 | #import "NSTimer+LMExtension.h" |
- 第二个问题,精度问题。NSTimer其实就是 CFRunLoopTimerRef,他们之间是 toll-free bridged 的,一个timer注册好后,RunLoop会在其重复的时间点注册事件,但是如果这个时候RunLoop正在处理一个其他任务的时候,错过了该事件点,则该次不会执行timer的事件源,会跳过当前时间点,直到下一个时间点才执行该timer的事件源。所以timer会有一个Tolerance的属性,这属性就是宽容度,该属性标记当时间点到了后,容许有多少的误差。
CADisplayLink
CADisplayLink是一个以屏幕刷新频率同步的计时器。以下是创建方法:
1 | 创建方法 |
CADisplayLink的计算时间并不依靠RunLoop,当一个屏幕刷新完成时候则会通知RunLoop给对应的target执行action。但是CADisplayLink依然会有精度的问题,当两次界面刷新之间执行了一次长任务的时候,那就会有一帧被跳过去,也就是所谓的掉帧,那相应的此次也不会调用target的action。
GCD
GCD提供了一个计时的方法,GCD定时器的底层是由XNU内核中的select方法实现的。具体的代码如下:
1 | dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0); |
总结
如果是对时间的要求不精确的计算,可以使用NSTimer,如果是对时间比较精确的,可以使用GCD提供的倒计时方法。如果是实现动画,需要高频率的绘制,可以使用CADisplayLink。